perf(cli): defer startup prefetch tasks#6303
Conversation
…up prefetch Address three test coverage gaps identified during code review: - Assert mockStartEarlyStartupPrefetches in both kitty protocol tests (C1: API preconnect call was wired but never verified) - Add Zed/ACP integration test verifying deferIdeConnection is false when getExperimentalZedIntegration returns true (C2: Zed path was entirely untested) - Assert mockStartBackgroundHousekeeping in startup-prefetch test (C3: unconditional housekeeping dispatch was never verified)
|
Thanks for the updated PR — re-running triage on the latest commits. Template looks good ✓ — all required headings, bilingual description, and reviewer test plan present. Problem: Real, observed optimization target. Interactive startup blocks on telemetry SDK init, update checks, and IDE connection before first paint. Related to parent issue #3011 on startup performance. Evidence in the PR body shows what gets loaded before render. Not theoretical hardening. Direction: Aligned. Startup performance is directly user-facing for a CLI tool. Deferring non-critical work past first render is well-established. Part of the same initiative as #3219 (startup profiler) and #3221 (lazy tool registration). Size: 17 files changed, ~292 production logic lines (additions + deletions) after excluding tests (1006 lines), docs (369 lines), and generated files (0 lines). Touches Approach: Scope feels right. The new All review findings from the prior round have been addressed:
Moving on to code review. 🔍 中文说明感谢更新的 PR——在最新提交上重新运行分诊。 模板完整 ✓ — 所有必要标题、双语说明和审查者测试计划齐全。 问题:真实的优化目标。交互式启动在首次渲染前阻塞了遥测 SDK 初始化、更新检查和 IDE 连接。关联父 issue #3011。PR 描述中有证据显示渲染前加载了什么。不是理论性加固。 方向:对齐。启动性能对 CLI 工具来说是直接影响用户体验的。将非关键工作延迟到首次渲染后是成熟的模式。属于 #3011 启动优化计划的一部分。 规模:17 个文件变更,排除测试(1006 行)、文档(369 行)和生成文件(0 行)后约 292 行生产逻辑。触及核心模块 方案:范围合理。新的 上轮审查的所有发现均已解决: 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewRe-reviewed the full diff against the latest commits (including
Footer + AppContainer + events — The IDE connection status UI is a well-scoped addition. No correctness bugs, security holes, or regressions found. All prior review findings addressed. Test ResultsUnit tests pass (worktree): Typecheck passes for all packages (core, cli, sdk, webui). CI: ubuntu-latest tests pass (27m5s). Real-Scenario TestingNon-interactive mode (
|
|
Stepping back to look at the full picture after the author's latest fixes: this PR is now in good shape. The original concerns — the The implementation is straightforward: one new module (160 lines) with clear contracts, minimal changes to existing files, and comprehensive test coverage (81 tests across 4 suites, all passing). The core change is 3 lines. The My independent proposal would have been essentially the same: consolidate fire-and-forget operations into a controller module, add deferred options, guard with idempotency. The PR exceeds that with lifecycle profiler events, The design doc is thorough but serves as useful documentation for future maintainers working on startup performance — not over-engineering. All CI green, typecheck clean, real-scenario tests confirm non-interactive parity and interactive TUI rendering. Approving. ✅ 中文说明在作者最新修复后回顾全局:这个 PR 现在状态良好。原始关注点—— 实现直截了当:一个 160 行的新模块,契约清晰,对现有文件变更最小,测试覆盖全面(4 个套件共 81 个测试全部通过)。核心变更仅 3 行。 CI 全绿,类型检查干净,真实场景测试确认非交互式行为一致和交互式 TUI 正常渲染。批准。✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
| debugLogger.warn( | ||
| `${name} failed: ${err instanceof Error ? err.message : String(err)}`, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
[Suggestion] runDeferredTask interpolates err.message into a string before passing it to debugLogger.warn, which drops the stack trace. The debugLogger's formatArgs already handles Error objects specially (extracting arg.stack), but it never sees the original error here.
| }); | |
| debugLogger.warn(`${name} failed:`, err); |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Addressed. runDeferredTask now passes the original error object to the logger:
debugLogger.warn(`${name} failed:`, err);This preserves stack traces through formatArgs, and the startup prefetch tests assert the logger receives the original Error object.
| preconnectApi(authType, { resolvedBaseUrl, proxy }); | ||
| recordStartupEvent('startup_prefetch_started', { name: 'api_preconnect' }); | ||
| } catch (error) { | ||
| debugLogger.debug( |
There was a problem hiding this comment.
[Suggestion] api_preconnect records startup_prefetch_started but never emits the corresponding startup_prefetch_completed or startup_prefetch_failed event. All post-render tasks go through runDeferredTask which records all three lifecycle events. This asymmetry means profiling consumers see api_preconnect as perpetually in-flight.
Consider either wrapping the call in runDeferredTask for consistency, or removing the manual recordStartupEvent here so only runDeferredTask-managed tasks appear in the startup_prefetch_* event stream.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Addressed. The manual startup_prefetch_started event for api_preconnect has been removed, so only tasks managed by runDeferredTask enter the balanced startup_prefetch_* lifecycle stream.
There is also a regression test asserting that api_preconnect does not emit an unbalanced lifecycle event.
| }); | ||
| } | ||
|
|
||
| runDeferredTask('background_housekeeping', async () => { |
There was a problem hiding this comment.
[Suggestion] The original code in gemini.tsx guarded housekeeping with if (config.isInteractive()) and a comment explaining that non-interactive modes don't create the file-history dirs this cleans. That guard was dropped in the migration.
This is safe today since startPostRenderPrefetches is only called from startInteractiveUI, but the function's signature accepts any Config — a future non-interactive caller would silently start housekeeping. Consider restoring the guard for defense-in-depth, or adding a comment noting the implicit invariant.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Addressed. background_housekeeping is now guarded by config.isInteractive() inside startPostRenderPrefetches(), preserving the previous invariant even if a future non-interactive caller invokes this helper directly.
The startup prefetch tests cover the non-interactive case and assert housekeeping is not scheduled.
doudouOUC
left a comment
There was a problem hiding this comment.
Startup deferral is well-structured with good idempotency guards. A few findings target files outside this PR's diff (WebShellSidebar, autofix workflow, errorReporting, acpAgent) — flagging them here since inline comments aren't possible on those files:
- WebShellSidebar (
WebShellSidebar.tsx):handleArchive/handleUnarchiveonly reload the target list, not the source list — sessions appear in both active and archived sections simultaneously.confirmDeleteSessionis missing thebusySessionIdRefguard that archive/unarchive both have, allowing concurrent operations. - Autofix workflow (
qwen-autofix.yml): The claim step removesautofix/approvedbefore posting the claim comment. If the comment fails, the issue becomes invisible to all future automated scans (stuck state). - Test gaps: Archive/unarchive error paths, archived session deletion,
actions.tsarchive/unarchive, andloadSession/resumeSessionconcurrent isolation are all untested. - acpAgent:
newSessionstill assignsthis.settingsbefore the firstawait, inconsistent withloadSession/resumeSession.
For files in the PR diff:
startup-prefetch.ts:96-99— IDE connection failure in deferred mode is onlydebugLogger.warn. The symptom (IDE features don't work) and cause (deferred connection swallowed) are far apart for debugging.config.ts:2014—deferTelemetryInitializationdefers forqwen -i "prompt"even though the first model request fires before post-render prefetches run. Consider excluding prompt-interactive from deferral, or add a comment explaining the accepted trade-off.
— qwen3.7-max via Qwen Code /review
Tighten the startup prefetch follow-up fixes from review while keeping prompt-interactive telemetry on the fast interactive startup path. - Preserve Error objects when deferred startup tasks fail - Remove the unbalanced api_preconnect profiler lifecycle event - Guard background housekeeping so it only runs for interactive configs - Document and test prompt-interactive telemetry deferral semantics
Ensure sessions launched with an initial interactive prompt have telemetry ready before the auto-submitted first request runs. - Exclude prompt-interactive startup from telemetry deferral - Pass a post-render telemetry option through interactive UI startup - Skip duplicate post-render telemetry startup for initial prompts - Update tests to cover the first-prompt telemetry guarantee Note: Plain interactive TUI startup still defers telemetry post-render.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI still running.
The deferred-startup design is well-structured — WeakSet idempotency guards are correct, all five startup paths (interactive, prompt-interactive, non-interactive, stream-json, ACP/Zed) maintain their correctness guarantees, and the new test suite covers the critical paths. A few low-confidence defensive-programming suggestions were identified but not flagged as inline comments (default-direction mismatch in startInteractiveUI options, duplicated telemetry defer condition across modules, and fragile ide_connect/telemetry_init ordering in startPostRenderPrefetches).
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI still running.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| if (options.initializeTelemetry) { | ||
| runDeferredTask('telemetry_init', () => { |
There was a problem hiding this comment.
[Suggestion] telemetry_init is the only synchronous task among the deferred tasks. initializeTelemetry(config) runs as a single microtask inside Promise.resolve().then(task), blocking the event loop until OTel SDK setup completes (resource attributes, exporters, BatchSpanProcessor, PeriodicExportingMetricReader). Every other deferred task starts with await import(...) which yields to the event loop before doing real work.
This has two consequences:
- Performance: The sync microtask can delay React's post-mount
useEffectstate updates and pending I/O, partially undermining the "fast time-to-interactive" goal. - Ordering dependency: Because
telemetry_initis declared beforeide_connectand runs synchronously, it completes beforeconnectIdeForStartup→logIdeConnectionexecutes.logIdeConnectionguards onisTelemetrySdkInitialized()and silently drops the event if false. This ordering is accidental — ifinitializeTelemetryis ever made async or the task declarations are reordered, IDE connection telemetry events will be silently dropped with no test catching it.
| runDeferredTask('telemetry_init', () => { | |
| if (options.initializeTelemetry) { | |
| runDeferredTask('telemetry_init', async () => { | |
| const { initializeTelemetry: initTelemetry } = await import( | |
| '@qwen-code/qwen-code-core' | |
| ); | |
| initTelemetry(config); | |
| }); | |
| } |
Making this async with a dynamic import (consistent with the other tasks) yields to the event loop before running, and also removes the static initializeTelemetry import at the top of the file. To preserve the ordering guarantee with ide_connect, either sequence them explicitly or add a comment documenting the dependency.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Not addressed in this commit.
I kept telemetry_init synchronous for now because changing it to an async dynamic import also changes the implicit ordering with deferred ide_connect; logIdeConnection currently depends on telemetry already being initialized. Fixing this properly should sequence telemetry and IDE connection explicitly, or introduce a shared readiness contract, instead of only adding an async import and risking dropped IDE telemetry events.
This should be handled as a separate follow-up so the scheduling and telemetry-ordering behavior can be reviewed together.
| } | ||
|
|
||
| if (options.connectIde && config.getIdeMode()) { | ||
| runDeferredTask('ide_connect', async () => { |
There was a problem hiding this comment.
[Suggestion] The deferred IDE connection runs fire-and-forget via runDeferredTask with no timeout. If the IDE IPC endpoint is unresponsive (VS Code extension host restarted, stale socket file, JetBrains backend overloaded), the connect() promise hangs indefinitely inside runDeferredTask. The catch handler only fires on rejection, not on hang. The result: startup_prefetch_started with name: ide_connect is recorded but neither completed nor failed ever fires, and there is no diagnostic trail explaining why IDE context is missing.
This is a new failure mode introduced by the PR. The old code awaited connect() inside initializeApp() before first paint — a hang was immediately visible as "the TUI never appears." The new code makes the same hang invisible.
Consider wrapping the connection in a timeout (e.g., Promise.race with a 10s setTimeout that rejects), so the profiler captures the outcome and debugLogger.warn fires on timeout.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Addressed in 6cba2c578.
Deferred ide_connect is now wrapped in a 10s timeout. If the IDE IPC promise hangs, the timeout rejects through runDeferredTask, so the profiler records startup_prefetch_failed and the debug logger emits a clear timeout diagnostic instead of leaving the task permanently in-flight.
A unit test covers the hanging connection case.
| initializationResult!, | ||
| { | ||
| postRenderConnectIde: deferIdeConnection, | ||
| postRenderInitializeTelemetry: !input, |
There was a problem hiding this comment.
[Suggestion] The telemetry deferral decision is split across two files with no type-level or comment-level link:
cli/config/config.tsdecides whether to skip eager init:deferTelemetryInitialization: interactive && !isAcpMode && !question- This line decides whether to run post-render init:
postRenderInitializeTelemetry: !input
The invariant deferTelemetryInitialization === postRenderInitializeTelemetry must hold for the interactive TUI path, or telemetry either never initializes (silent data loss) or initializes twice (masked today by the telemetryInitialized guard in sdk.ts, but that guard is an implementation detail that could change).
Today the conditions are equivalent (interactive && !isAcpMode && !question simplifies to !question on the interactive path, matching !input). But this relies on three unstated premises: ACP mode never reaches this branch, question and input are the same value, and no future condition is added to either side independently.
Consider adding a linking comment at both sites, or computing the deferral flag once and passing it through to both loadCliConfig and startInteractiveUI.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Addressed in 6cba2c578 with the Config-backed option.
Config now stores the telemetry deferral decision as telemetryInitializationDeferred, and startInteractiveUI() defaults post-render telemetry startup from config.isTelemetryInitializationDeferred() instead of recomputing a separate !input condition in gemini.tsx.
This keeps the eager-skip and post-render-init decisions tied to the same source of truth.
| expect(config.getTelemetryEnabled()).toBe(true); | ||
| }); | ||
|
|
||
| it('should initialize telemetry before prompt-interactive startup', async () => { |
There was a problem hiding this comment.
[Suggestion] This test verifies deferTelemetryInitialization: false for prompt-interactive, but there is no corresponding positive test verifying deferTelemetryInitialization: true for the ordinary interactive case (no -p, no -i "prompt", not ACP). The positive path is the primary motivation for the feature — deferring telemetry for ordinary TUI startup — yet it has no test. A regression that accidentally always sets false would go undetected.
Consider adding a test with process.argv = ['node', 'script.js', '-i'] (interactive, no question) that asserts deferTelemetryInitialization: true.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Addressed in 6cba2c578.
Added a positive loadCliConfig test for ordinary interactive startup that sets process.stdin.isTTY = true, uses no initial prompt, and asserts deferTelemetryInitialization: true is passed to Config.
| const { initializeApp } = await import('./core/initializer.js'); | ||
| const initializationResult = await initializeApp(config, settings); | ||
| let input = config.getQuestion(); | ||
| const deferIdeConnection = |
There was a problem hiding this comment.
[Critical] deferIdeConnection only treats a CLI question as an immediate first request, but interactive sessions with --input-file can submit as soon as RemoteInputWatcher.setSubmitFn() is installed. Since IDE connection is now fire-and-forget post-render, an IDE-enabled driver can send that first request before IDE context is connected, regressing the same first-request guarantee this PR preserves for prompt-interactive startup.
| const deferIdeConnection = | |
| const hasImmediateInteractiveSubmit = Boolean( | |
| input || config.getInputFile?.(), | |
| ); | |
| const deferIdeConnection = | |
| config.isInteractive() && | |
| !config.getExperimentalZedIntegration() && | |
| !hasImmediateInteractiveSubmit; |
— gpt-5 via Qwen Code /review
There was a problem hiding this comment.
Addressed in 6cba2c578.
deferIdeConnection now treats config.getInputFile?.() as an immediate interactive-submit source, so interactive sessions using --input-file keep the IDE connection awaited before the first remote prompt can run. The post-render prefetch receives connectIde: false in that case to avoid a duplicate connection attempt.
Added a gemini.test.tsx regression test for interactive + input file.
# Conflicts: # packages/cli/src/gemini.test.tsx
| experimentalLsp: undefined, | ||
| channel: undefined, | ||
| chatRecording: undefined, | ||
| sessionId: undefined, |
There was a problem hiding this comment.
[Critical] [build] fallbackModel is missing from this mock CliArgs object. The same omission exists at line 1511. CliArgs.fallbackModel is required (no ?), so tsc --build reports TS2345 on both lines. Line 1267 already includes fallbackModel: undefined — these two were missed.
| sessionId: undefined, | |
| sessionId: undefined, | |
| fallbackModel: undefined, |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Addressed in 6cba2c578.
Both affected mock CliArgs objects now include fallbackModel: undefined, and packages/cli typecheck passes with tsc --noEmit.
|
|
||
| await vi.dynamicImportSettled(); | ||
|
|
||
| expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); |
There was a problem hiding this comment.
[Suggestion] handleAutoUpdate is mocked (line 20) and wired into the handleAutoUpdate module mock (line 46), but no test asserts it was actually called. This test verifies checkForUpdates was called but skips the second half of the update pipeline — a regression that drops handleAutoUpdate from the .then() chain would pass all tests.
| expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); | |
| expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); | |
| expect(mockHandleAutoUpdate).toHaveBeenCalledWith( | |
| null, | |
| expect.any(Object), | |
| '/repo', | |
| ); |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Not addressed in this commit.
I did not add this assertion because the current test intentionally uses a never-resolving checkForUpdates() promise to verify that startPostRenderPrefetches() starts work without awaiting completion. In that specific test, handleAutoUpdate should not run.
A separate test that resolves checkForUpdates() and asserts the handleAutoUpdate(info, settings, projectRoot) handoff would be useful, but it is a test-strengthening follow-up rather than part of the first-request correctness fixes in 6cba2c578.
Keep deferred startup work from weakening first-request behavior in interactive sessions that submit prompts automatically or remotely. - Store telemetry deferral on Config and reuse that decision at render time - Keep IDE startup awaited for prompt-interactive and input-file sessions - Add a timeout for deferred IDE connection failures - Cover ordinary interactive telemetry deferral and IDE startup edge cases
|
Thanks for the detailed review. Status after Fixed in this PR:
Not fixed here:
|
| }); | ||
| } | ||
| startPostRenderPrefetches(config, settings, { | ||
| connectIde: options.postRenderConnectIde ?? true, |
There was a problem hiding this comment.
[Suggestion] The default options.postRenderConnectIde ?? true makes post-render IDE connection opt-out. Combined with initializeApp()'s default deferIdeConnection: false (eager), any future caller of startInteractiveUI that omits options will get a double IDE connection: once awaited in initializeApp, once fire-and-forget in startPostRenderPrefetches. IdeClient.connect() has no re-entry guard.
The sole production caller in gemini.tsx passes the option correctly, masking this footgun.
| connectIde: options.postRenderConnectIde ?? true, | |
| connectIde: options.postRenderConnectIde ?? false, |
Changing the default to false makes the safe/already-connected state the default, matching initializeApp's eager-connect behavior. The gemini.tsx caller already passes postRenderConnectIde: deferIdeConnection explicitly, so no caller change is needed.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Addressed in d05359056.
startInteractiveUI() now defaults postRenderConnectIde to false, so the already-connected/eager IDE path is the safe default and future callers that omit options will not accidentally connect twice. The production caller in gemini.tsx still passes postRenderConnectIde: deferIdeConnection explicitly, so ordinary TUI startup keeps the deferred IDE behavior when intended.
Updated startInteractiveUI tests to assert the safer default. Verified with:
cd packages/cli && npx vitest run src/gemini.test.tsx
cd packages/cli && npm run typecheck
git diff --checkDefault startInteractiveUI to the already-connected IDE path so future callers do not accidentally connect twice when initializeApp used its eager default. - Change the post-render IDE connection default to false - Update startInteractiveUI tests to assert the safer default
|
@wenshao @doudouOUC All the comments have been addressed. Please take another look. Thanks. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI still running.
The startup deferral architecture is well-structured: WeakSet idempotency guards are correct, the runDeferredTask abstraction cleanly separates fire-and-forget work from the critical path, and all five startup flows (interactive, prompt-interactive, non-interactive, ACP/Zed, input-file) are properly handled with comprehensive test coverage (77/77 passing). Typecheck and ESLint clean.
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Code Review
Approve ✅ — no Critical findings. All findings are Suggestions.
The fire-and-forget prefetch design is well-structured: WeakSet idempotency guards are correct, runDeferredTask cleanly separates concerns, the five startup paths maintain their correctness invariants, and all 336 tests pass across 5 suites.
Suggestions
- [startup-prefetch.ts:37]
withTimeoutmasks the underlying promise's real error when the timeout fires first — after the timeout winsPromise.race, the original promise's rejection becomes unhandled. Consider attaching a.catch()to the losing promise. - [gemini.tsx:756] IDE extension users see an interactive input area before the IDE connection completes — commands sent in that window lack IDE context. Consider a visual indicator.
- [config.ts:2055] Two accepted tradeoffs at this call site: (a) auth telemetry events emitted during the deferred init window are no-ops (acknowledged in the PR risk section), and (b) IDE deferral and telemetry deferral use different conditions (
questionmatters for IDE but not telemetry) with no cross-reference explaining the asymmetry. - [startup-prefetch.ts:108]
handleAutoUpdateperforms synchronous file-system I/O inside a fire-and-forget task — acceptable for small files, but worth documenting the assumption. - [startup-prefetch.test.ts:147] No test asserts that
startup_prefetch_completedis recorded byrunDeferredTask. The code does it correctly, but a profiler lifecycle test would catch future regressions. - [initializer.test.ts:199]
connectIdeForStartupis tested withgetIdeMode()=truebut not directly withgetIdeMode()=false— the early-return path lacks a dedicated unit test.
— qwen3.7-max via Qwen Code /review
| }); | ||
|
|
||
| return Promise.race([promise, timeout]).finally(() => { | ||
| if (timeoutId) { |
There was a problem hiding this comment.
[Suggestion] withTimeout masks the real error when the timeout fires first. After Promise.race resolves with the timeout rejection, the original promise is still running — and if it eventually rejects, that rejection is silently consumed (no .catch() attached to the losing branch). The debug log will only show the timeout error, losing the actual root cause.
Consider attaching a .catch() to the losing promise to surface its error:
return Promise.race([promise, timeout]).finally(() => {
clearTimeout(timeoutId);
});Alternatively, log the underlying error in a separate .catch() on promise itself (e.g., promise.catch(err => debugLogger.debug(\${name} underlying error:`, err))`) so it doesn't mask the timeout but still surfaces the real cause.
There was a problem hiding this comment.
Addressed in 95c5f6c03.
I added a losing-promise catch in the deferred timeout helper so if ide_connect times out first and the underlying IDE connection later rejects, the original error is logged at debug level without changing the externally observed timeout failure.
| const initializationResult = await initializeApp(config, settings); | ||
| let input = config.getQuestion(); | ||
| const hasRemoteInput = Boolean(config.getInputFile?.()); | ||
| const deferIdeConnection = |
There was a problem hiding this comment.
[Suggestion] The deferred IDE connection creates a race window for IDE extension users: the TUI renders with an interactive input area before the IDE IPC handshake completes. If a user types and submits a message during that window, the model request goes out without IDE context (workspace info, editor state).
For IDE extension sessions specifically, consider showing a brief "Connecting to IDE..." status indicator until ide_connect completes, or deferring input availability until the deferred IDE connection resolves. Ordinary CLI sessions are unaffected since connectIde is opt-in.
There was a problem hiding this comment.
Addressed in 95c5f6c03 with the status-only approach.
Ordinary interactive IDE TUI startup now emits deferred IDE connection lifecycle status and surfaces it in the footer: connecting shows IDE connecting... context may be unavailable, success clears the indicator, and failure/timeout leaves a warning. This keeps input available and preserves the post-render startup performance tradeoff while making the race window visible to users.
| telemetry: telemetrySettings, | ||
| // Prompt-interactive (`qwen -i "prompt"`) auto-submits its first prompt, | ||
| // so telemetry must be ready before that request is sent. | ||
| deferTelemetryInitialization: interactive && !isAcpMode && !question, |
There was a problem hiding this comment.
[Suggestion] Two accepted tradeoffs converge at this line, and it would help future readers to document both:
- Auth telemetry gap: auth-related events emitted between
performInitialAuth()(which runs eagerly ininitializeApp) and the deferredinitializeTelemetry()call are no-ops. This is acknowledged in the PR risk section. - Asymmetric deferral conditions:
deferIdeConnection(in gemini.tsx) considersquestion(prompt-interactive doesn't defer IDE), butdeferTelemetryInitializationhere does not checkquestion. The design rationale is sound — prompt-interactive needs IDE context for the auto-submitted prompt but doesn't need telemetry before the first request — but a cross-reference comment would prevent future maintainers from "fixing" the asymmetry.
There was a problem hiding this comment.
Addressed in 95c5f6c03.
I documented the accepted telemetry deferral tradeoffs at the deferTelemetryInitialization decision point: auth events before deferred telemetry init may be no-ops, and the telemetry condition is intentionally not identical to IDE deferral because prompt-interactive needs IDE context before auto-submit while telemetry can remain post-render unless an initial prompt is present.
| import('../utils/handleAutoUpdate.js'), | ||
| ]); | ||
| const info = await checkForUpdates(); | ||
| handleAutoUpdate(info, settings, config.getProjectRoot()); |
There was a problem hiding this comment.
[Suggestion] handleAutoUpdate(info, settings, config.getProjectRoot()) performs synchronous file-system I/O (writing update-notification files). Inside a fire-and-forget deferred task, this blocks the event loop briefly. For small files this is acceptable, but it's worth documenting the assumption that handleAutoUpdate is expected to be fast — or wrapping it in a Promise.resolve() boundary so it doesn't block other microtasks queued by the prefetch batch.
There was a problem hiding this comment.
Not changed in this pass.
handleAutoUpdate still runs inside the post-render fire-and-forget task, and the synchronous work here is limited to small update-notification file I/O after first paint. Wrapping it in another microtask would not remove the synchronous I/O cost, and there is no current evidence that it causes visible startup contention. I would keep this as a follow-up only if profiling shows the update file write is material.
|
|
||
| await vi.dynamicImportSettled(); | ||
|
|
||
| expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); |
There was a problem hiding this comment.
[Suggestion] No test in this suite asserts that recordStartupEvent('startup_prefetch_completed', { name }) is actually called by runDeferredTask. The production code does this correctly (line 56), but a profiler lifecycle test would catch regressions if someone restructures the .then() chain. Consider adding an assertion like:
expect(recordStartupEvent).toHaveBeenCalledWith(
'startup_prefetch_completed',
expect.objectContaining({ name: 'update_check' }),
);There was a problem hiding this comment.
Addressed in 95c5f6c03.
I added a successful deferred update_check lifecycle test that asserts both startup_prefetch_started and startup_prefetch_completed are recorded, so the profiler completion contract is now covered alongside the failure path.
| expect(mockLogIdeConnection).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should not connect to IDE when not in IDE mode', async () => { |
There was a problem hiding this comment.
[Suggestion] connectIdeForStartup is tested at line 189 with getIdeMode()=true, but the early-return path (getIdeMode()=false → skip connection) is only tested indirectly through initializeApp at line 200. A dedicated unit test calling connectIdeForStartup(mockConfig) with getIdeMode()=false and asserting no IDE client calls would better document the contract and catch regressions if the guard condition changes.
There was a problem hiding this comment.
Addressed in 95c5f6c03.
I added a direct connectIdeForStartup() early-return test for getIdeMode() === false, asserting that IdeClient.getInstance(), connect(), and logIdeConnection() are not called. This locks the shared helper contract directly instead of only covering it through initializeApp().
Make ordinary interactive IDE startup visible while preserving the post-render prefetch path and first-paint performance tradeoff. - Emit deferred IDE connection lifecycle events for connecting, success, and failure states - Surface IDE startup status in the TUI footer without blocking input - Log late underlying IDE failures after timeout for better diagnostics - Document telemetry deferral tradeoffs and add startup lifecycle tests
|
@qwen-code /triage |
| } | ||
|
|
||
| if (options.connectIde && config.getIdeMode()) { | ||
| runDeferredTask('ide_connect', async () => { |
There was a problem hiding this comment.
[Suggestion] When withTimeout fires at 10s, the underlying connectIdeForStartup promise is not cancelled — it continues running. If the IDE connection succeeds after the timeout (e.g., at second 11-12), IdeClient becomes connected and workspace data flows, but the UI permanently shows "IDE connection unavailable" because no connected status event is emitted after the timeout path. The failed state in StartupIdeConnectionStatus is terminal — there is no late-success recovery or disconnect on timeout.
This creates a state inconsistency where the footer claims the IDE is unavailable while it is actually connected and functional. Users may disable IDE integration or file bugs based on the misleading status.
Consider either:
- Disconnecting the IDE client on timeout so the internal state stays consistent with the UI, or
- Keeping a reference to the underlying promise and emitting
{ state: 'connected' }if it eventually succeeds after the timeout.
— qwen3.7-max via Qwen Code /review
| } | ||
| }); | ||
|
|
||
| it('fails deferred IDE connection when the startup connect hangs', async () => { |
There was a problem hiding this comment.
[Suggestion] The test suite covers the timeout scenario (hanging promise + 10s advance) and the success path, but there is no test for a quick IDE connection failure before the timeout fires. This is a distinct code branch: the catch block at the end of the ide_connect task extracts the error message via err instanceof Error ? err.message : String(err) and emits { state: 'failed', message }. A quick-rejection test (e.g., mockConnectIdeForStartup.mockRejectedValue(new Error('connection refused'))) would verify this path and the error message extraction logic.
it('fails deferred IDE connection when the startup connect rejects quickly', async () => {
const config = makeConfig();
mockConnectIdeForStartup.mockRejectedValue(new Error('connection refused'));
const { statuses, stop } = captureIdeConnectionStatuses();
try {
startPostRenderPrefetches(config, makeSettings(), { connectIde: true });
await vi.dynamicImportSettled();
expect(statuses).toEqual([
{ state: 'connecting' },
{ state: 'failed', message: 'connection refused' },
]);
expect(mockRecordStartupEvent).toHaveBeenCalledWith(
'startup_prefetch_failed',
{ name: 'ide_connect' },
);
} finally {
stop();
}
});— qwen3.7-max via Qwen Code /review
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. All prior review findings (input-file race, double-connection footgun, fallbackModel mock, IDE timeout) are resolved. ✅



… (#3222)
What this PR does
This PR moves interactive telemetry SDK startup off the pre-render REPL critical path and starts it after the first Ink render alongside the
existing post-render startup prefetch work. It also keeps IDE startup awaited for prompt-interactive sessions that auto-submit an initial
prompt, while avoiding a duplicate post-render IDE connection in that path.
Why it's needed
Interactive startup should render the REPL before optional background work such as telemetry initialization, update checks, IDE connection for
ordinary TUI sessions, and housekeeping completes. At the same time, prompt-interactive and non-interactive flows must keep their first-
request correctness guarantees, especially IDE context and MCP tool availability.
Reviewer Test Plan
How to verify
Confirm that ordinary interactive startup defers telemetry initialization and IDE connection until after first render, while prompt-
interactive still awaits IDE context before the auto-submitted first request and does not connect IDE a second time after render. Confirm that
non-interactive behavior is unchanged and still waits for MCP readiness before the first model request.
Local verification commands run:
All commands passed locally.
Evidence (Before & After)
Before: Telemetry and checkForUpdates were loaded before rendering in interactive CLI startup.
After: Telemetry and checkForUpdates are no longer loaded before rendering in interactive CLI startup.
Tested on
Environment (optional)
Local npm workspace on macOS; verification used package-level TypeScript checks and targeted Vitest suites.
Risk & Scope
Main risk or tradeoff: Interactive telemetry events emitted before the post-render telemetry startup can be no-ops because this change
intentionally does not add buffering.
Not validated / out of scope: Manual TUI timing capture, Windows-specific behavior, Linux-specific behavior, and full integration suites
were not run locally.
Breaking changes / migration notes: None expected. Non-interactive, stream-json, ACP/Zed, and MCP first-request readiness semantics are
preserved.
Linked Issues
Related to #3011 and #3222.
中文说明
What this PR does
本 PR 将交互式遥测 SDK 的启动从预渲染 REPL 关键路径中移出,改为在首次 Ink 渲染后与现有的渲染后启动预取工作一同启动。同时,对于自动提交初始提示词的 prompt-interactive 会话,保持 IDE 启动的 await 行为,并避免在该路径中重复进行渲染后的 IDE 连接。
Why it's needed
交互式启动应在渲染 REPL 之后再执行可选的后台工作,如遥测初始化、更新检查、普通 TUI 会话的 IDE 连接以及内务处理。与此同时,prompt-interactive 和非交互式流程必须保持其首次请求的正确性保证,尤其是 IDE 上下文和 MCP 工具可用性。
Reviewer Test Plan
How to verify
确认普通交互式启动将遥测初始化和 IDE 连接延迟到首次渲染之后,而 prompt-interactive 在自动提交的首次请求前仍会 await IDE 上下文,且不会在渲染后再次连接 IDE。确认非交互式行为保持不变,仍在首次模型请求前等待 MCP 就绪。
本地验证命令运行通过:
所有命令在本地均通过。
Evidence (Before & After)
修改前:对于普通的交互式的命令行启动方式,会在 Render 前加载 telemetry, 并执行 checkForUpdates
修改前:对于普通的交互式的命令行启动方式,不会在 Render 前加载 telemetry 和 checkForUpdates
Tested on
Environment (optional)
macOS 上的本地 npm workspace;验证使用了包级别的 TypeScript 检查和针对性的 Vitest 套件。
Risk & Scope
-主要风险或权衡:在渲染后遥测启动之前发出的交互式遥测事件可能为 no-op,因为本次变更有意不添加缓冲机制。
未验证 / 超出范围:手动 TUI 时序捕获、Windows 特定行为、Linux 特定行为以及完整集成套件未在本地运行。
破坏性变更 / 迁移说明:无预期变更。非交互式、stream-json、ACP/Zed 以及 MCP 首次请求就绪语义保持不变。
Linked Issues
关联 #3011 和 #3222。